summaryrefslogtreecommitdiff
path: root/app/[lng]
diff options
context:
space:
mode:
Diffstat (limited to 'app/[lng]')
-rw-r--r--app/[lng]/admin/edp-progress-debug/page.tsx210
-rw-r--r--app/[lng]/admin/edp-progress/page.tsx488
-rw-r--r--app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx11
-rw-r--r--app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx112
-rw-r--r--app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx67
-rw-r--r--app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx97
7 files changed, 564 insertions, 423 deletions
diff --git a/app/[lng]/admin/edp-progress-debug/page.tsx b/app/[lng]/admin/edp-progress-debug/page.tsx
new file mode 100644
index 00000000..ebaa07a2
--- /dev/null
+++ b/app/[lng]/admin/edp-progress-debug/page.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { debugVendorFieldCalculation } from '@/lib/forms/vendor-completion-stats';
+import { Loader, Search, FileText, Tag, CheckCircle, XCircle } from 'lucide-react';
+import { toast } from 'sonner';
+
+export default function DebugVendorFieldsPage() {
+ const [loading, setLoading] = React.useState(false);
+ const [vendorId, setVendorId] = React.useState('1');
+ const [debugData, setDebugData] = React.useState<any>(null);
+
+ const handleDebug = async () => {
+ setLoading(true);
+ setDebugData(null);
+
+ try {
+ const result = await debugVendorFieldCalculation(Number(vendorId));
+ setDebugData(result);
+
+ if (result) {
+ toast.success(`${result.vendorName}의 필드 계산 디버그 완료`);
+ } else {
+ toast.warning('벤더 데이터가 없습니다');
+ }
+ } catch (error) {
+ console.error('Error debugging vendor fields:', error);
+ toast.error(`디버그 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const renderFieldDetails = (fieldDetails: any[]) => (
+ <div className="space-y-1">
+ {fieldDetails.map((field, index) => (
+ <div key={index} className="flex items-center gap-2 text-xs">
+ <span className="font-mono bg-muted px-1 rounded">{field.fieldKey}</span>
+ <span className="text-muted-foreground">=</span>
+ <span className="font-mono">{String(field.fieldValue ?? 'null')}</span>
+ {field.isEmpty ? (
+ <XCircle className="h-3 w-3 text-red-500" />
+ ) : (
+ <CheckCircle className="h-3 w-3 text-green-500" />
+ )}
+ </div>
+ ))}
+ </div>
+ );
+
+ return (
+ <div className="container mx-auto p-6 space-y-6">
+ <div className="flex items-center gap-2 mb-6">
+ <Search className="h-6 w-6" />
+ <h1 className="text-3xl font-bold">벤더 필드 계산 디버그</h1>
+ </div>
+
+ {/* Input */}
+ <Card>
+ <CardHeader>
+ <CardTitle>벤더 ID 입력</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendorId">Vendor ID</Label>
+ <Input
+ id="vendorId"
+ value={vendorId}
+ onChange={(e) => setVendorId(e.target.value)}
+ placeholder="1"
+ type="number"
+ />
+ </div>
+ <div className="flex items-end">
+ <Button onClick={handleDebug} disabled={loading}>
+ {loading ? <Loader className="h-4 w-4 animate-spin mr-2" /> : <Search className="h-4 w-4 mr-2" />}
+ 디버그 실행
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* Results */}
+ {debugData && (
+ <div className="space-y-4">
+ {/* Summary */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ {debugData.vendorName} - 전체 요약
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+ <div className="text-center">
+ <div className="text-2xl font-bold text-blue-600">
+ {debugData.debugInfo.grandTotal.totalRequiredFields}
+ </div>
+ <p className="text-sm text-muted-foreground">전체 필드</p>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-green-600">
+ {debugData.debugInfo.grandTotal.totalFilledFields}
+ </div>
+ <p className="text-sm text-muted-foreground">입력 필드</p>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-red-600">
+ {debugData.debugInfo.grandTotal.totalEmptyFields}
+ </div>
+ <p className="text-sm text-muted-foreground">빈 필드</p>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-purple-600">
+ {debugData.debugInfo.grandTotal.completionPercentage}%
+ </div>
+ <p className="text-sm text-muted-foreground">완성도</p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* Detailed Breakdown */}
+ <Card>
+ <CardHeader>
+ <CardTitle>상세 분석</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <ScrollArea className="h-96">
+ <div className="space-y-4">
+ {debugData.debugInfo.contracts.map((contract: any, contractIndex: number) => (
+ <div key={contractIndex} className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Tag className="h-4 w-4" />
+ <span className="font-semibold">
+ 계약 {contract.contractId} - {contract.projectName}
+ </span>
+ <Badge variant="outline">
+ 전체: {contract.totalRequiredFields} | 입력: {contract.totalFilledFields}
+ </Badge>
+ </div>
+
+ <div className="space-y-3 ml-4">
+ {contract.forms.map((form: any, formIndex: number) => (
+ <div key={formIndex} className="border-l-2 border-muted pl-4">
+ <div className="flex items-center gap-2 mb-2">
+ <FileText className="h-3 w-3" />
+ <span className="font-medium">{form.formName} ({form.formCode})</span>
+ <Badge variant="secondary" className="text-xs">
+ 전체: {form.totalRequiredFields} | 입력: {form.totalFilledFields}
+ </Badge>
+ </div>
+
+ <div className="space-y-2 ml-4">
+ {form.tags.map((tag: any, tagIndex: number) => (
+ <div key={tagIndex} className="bg-muted/50 rounded p-2">
+ <div className="flex items-center gap-2 mb-1">
+ <Tag className="h-3 w-3" />
+ <span className="font-mono text-sm">{tag.tagNo}</span>
+ <Badge variant="outline" className="text-xs">
+ 전체: {tag.requiredFieldsCount} | 입력: {tag.filledFieldsCount}
+ </Badge>
+ </div>
+
+ <div className="ml-4">
+ <div className="text-xs text-muted-foreground mb-1">
+ 편집 가능한 필드: {tag.editableFields.join(', ')}
+ </div>
+ {renderFieldDetails(tag.fieldDetails)}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </CardContent>
+ </Card>
+
+ {/* Raw Data */}
+ <Card>
+ <CardHeader>
+ <CardTitle>원시 데이터 (JSON)</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <ScrollArea className="h-64">
+ <pre className="text-xs bg-muted p-4 rounded overflow-auto">
+ {JSON.stringify(debugData, null, 2)}
+ </pre>
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/admin/edp-progress/page.tsx b/app/[lng]/admin/edp-progress/page.tsx
index 4efb739c..c42a1db7 100644
--- a/app/[lng]/admin/edp-progress/page.tsx
+++ b/app/[lng]/admin/edp-progress/page.tsx
@@ -2,430 +2,134 @@
import React from 'react';
import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
-import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
-import {
- calculateVendorFormCompletion,
- getProjectVendorCompletionSummary,
- calculateVendorContractCompletion,
- getVendorAllContractsCompletionSummary,
- getAllVendorsContractsCompletionSummary,
- getAllProjectsVendorCompletionSummary,
- type VendorFormCompletionStats,
- type ProjectVendorCompletionSummary,
- type VendorAllContractsCompletionSummary
-} from '@/lib/forms/vendor-completion-stats';
-import { Loader, TestTube, BarChart, FileText, TrendingUp } from 'lucide-react';
+import { getAllVendorsContractsCompletionSummary } from '@/lib/forms/vendor-completion-stats';
+import { Loader, Users, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
-interface TestResult {
- type: string;
- data: VendorFormCompletionStats | ProjectVendorCompletionSummary | VendorAllContractsCompletionSummary | unknown;
+interface VendorProgress {
+ vendorId: number;
+ vendorName: string;
+ totalForms: number;
+ tagCount: number;
+ totalRequiredFields: number;
+ totalFilledFields: number;
+ completionPercentage: number;
}
export default function EDPProgressTestPage() {
- const [loading, setLoading] = React.useState<string | null>(null);
- const [results, setResults] = React.useState<TestResult | null>(null);
-
- // Form inputs
- const [contractItemId, setContractItemId] = React.useState('123');
- const [formCode, setFormCode] = React.useState('SPR_LST');
- const [projectId, setProjectId] = React.useState('1');
- const [vendorId, setVendorId] = React.useState('1');
+ const [loading, setLoading] = React.useState(false);
+ const [vendorProgress, setVendorProgress] = React.useState<VendorProgress[]>([]);
- const handleTest = async (testType: string, testFunction: () => Promise<unknown>) => {
- setLoading(testType);
- setResults(null);
+ const loadVendorProgress = async () => {
+ setLoading(true);
try {
- const result = await testFunction();
- setResults({ type: testType, data: result });
+ const result = await getAllVendorsContractsCompletionSummary();
- if (result) {
- toast.success(`${testType} 테스트 완료`);
+ if (result && result.vendors) {
+ const progressData: VendorProgress[] = result.vendors.map(vendor => ({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ totalForms: vendor.totalForms,
+ tagCount: vendor.totalTags,
+ totalRequiredFields: vendor.totalRequiredFields,
+ totalFilledFields: vendor.totalFilledFields,
+ completionPercentage: vendor.overallCompletionPercentage
+ }));
+
+ setVendorProgress(progressData);
+ toast.success(`${progressData.length}개 벤더의 진척도를 불러왔습니다`);
} else {
- toast.warning(`${testType} 결과가 없습니다`);
+ toast.warning('벤더 데이터가 없습니다');
}
} catch (error) {
- console.error(`Error in ${testType}:`, error);
- toast.error(`${testType} 테스트 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
+ console.error('Error loading vendor progress:', error);
+ toast.error(`벤더 진척도 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
} finally {
- setLoading(null);
+ setLoading(false);
}
};
- const renderVendorFormStats = (stats: VendorFormCompletionStats) => (
- <div className="space-y-4">
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold text-green-600">{stats.completionPercentage}%</div>
- <p className="text-sm text-muted-foreground">완성도</p>
- </CardContent>
- </Card>
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold">{stats.totalFilledFields}</div>
- <p className="text-sm text-muted-foreground">입력된 필드</p>
- </CardContent>
- </Card>
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold">{stats.totalRequiredFields}</div>
- <p className="text-sm text-muted-foreground">총 필드</p>
- </CardContent>
- </Card>
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold">{stats.tagCount}</div>
- <p className="text-sm text-muted-foreground">태그 수</p>
- </CardContent>
- </Card>
- </div>
-
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">태그별 세부 현황</CardTitle>
- </CardHeader>
- <CardContent>
- <ScrollArea className="h-48">
- <div className="space-y-2">
- {stats.detailsByTag.map((tag, index) => (
- <div key={index} className="flex items-center justify-between p-2 border rounded">
- <span className="font-medium">{tag.tagNo}</span>
- <div className="flex items-center gap-2">
- <Badge variant={tag.completionPercentage >= 80 ? "default" : tag.completionPercentage >= 50 ? "secondary" : "destructive"}>
- {tag.completionPercentage}%
- </Badge>
- <span className="text-sm text-muted-foreground">
- {tag.filledFields}/{tag.requiredFields}
- </span>
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </CardContent>
- </Card>
- </div>
- );
-
- const renderProjectSummary = (summary: ProjectVendorCompletionSummary) => (
- <div className="space-y-4">
- <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold text-blue-600">{summary.averageCompletionPercentage}%</div>
- <p className="text-sm text-muted-foreground">평균 완성도</p>
- </CardContent>
- </Card>
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold">{summary.totalVendors}</div>
- <p className="text-sm text-muted-foreground">참여 벤더</p>
- </CardContent>
- </Card>
- <Card>
- <CardContent className="p-4">
- <div className="text-lg font-bold">{summary.projectCode}</div>
- <p className="text-sm text-muted-foreground">프로젝트 코드</p>
- </CardContent>
- </Card>
- </div>
-
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">벤더별 완성도</CardTitle>
- </CardHeader>
- <CardContent>
- <ScrollArea className="h-48">
- <div className="space-y-2">
- {summary.vendors.map((vendor, index) => (
- <div key={index} className="flex items-center justify-between p-2 border rounded">
- <span className="font-medium">{vendor.vendorName}</span>
- <Badge variant={vendor.completionPercentage >= 80 ? "default" : vendor.completionPercentage >= 50 ? "secondary" : "destructive"}>
- {vendor.completionPercentage}%
- </Badge>
- </div>
- ))}
- </div>
- </ScrollArea>
- </CardContent>
- </Card>
- </div>
- );
-
- const renderVendorAllContracts = (summary: VendorAllContractsCompletionSummary) => (
- <div className="space-y-4">
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold text-purple-600">{summary.overallCompletionPercentage}%</div>
- <p className="text-sm text-muted-foreground">전체 완성도</p>
- </CardContent>
- </Card>
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold">{summary.totalContracts}</div>
- <p className="text-sm text-muted-foreground">총 계약</p>
- </CardContent>
- </Card>
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold">{summary.totalForms}</div>
- <p className="text-sm text-muted-foreground">총 폼</p>
- </CardContent>
- </Card>
- <Card>
- <CardContent className="p-4">
- <div className="text-2xl font-bold">{summary.totalFilledFields}/{summary.totalRequiredFields}</div>
- <p className="text-sm text-muted-foreground">입력 필드</p>
- </CardContent>
- </Card>
- </div>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">프로젝트별 분석</CardTitle>
- </CardHeader>
- <CardContent>
- <ScrollArea className="h-48">
- <div className="space-y-2">
- {summary.projectBreakdown.map((project, index) => (
- <div key={index} className="flex items-center justify-between p-2 border rounded">
- <div>
- <div className="font-medium">{project.projectName}</div>
- <div className="text-sm text-muted-foreground">
- 계약 {project.contractsCount}개, 폼 {project.formsCount}개
- </div>
- </div>
- <Badge variant={project.completionPercentage >= 80 ? "default" : "secondary"}>
- {project.completionPercentage}%
- </Badge>
- </div>
- ))}
- </div>
- </ScrollArea>
- </CardContent>
- </Card>
-
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">계약별 세부 현황</CardTitle>
- </CardHeader>
- <CardContent>
- <ScrollArea className="h-48">
- <div className="space-y-2">
- {summary.contracts.map((contract, index) => (
- <div key={index} className="flex items-center justify-between p-2 border rounded">
- <div>
- <div className="font-medium">{contract.itemName}</div>
- <div className="text-sm text-muted-foreground">
- {contract.projectName} - 폼 {contract.totalForms}개
- </div>
- </div>
- <Badge variant={contract.averageCompletionPercentage >= 80 ? "default" : "secondary"}>
- {contract.averageCompletionPercentage}%
- </Badge>
- </div>
- ))}
- </div>
- </ScrollArea>
- </CardContent>
- </Card>
- </div>
- </div>
- );
+ React.useEffect(() => {
+ loadVendorProgress();
+ }, []);
return (
<div className="container mx-auto p-6 space-y-6">
- <div className="flex items-center gap-2 mb-6">
- <TestTube className="h-6 w-6" />
- <h1 className="text-3xl font-bold">EDP Progress 서버 액션 테스트</h1>
+ <div className="flex items-center justify-between mb-6">
+ <div className="flex items-center gap-2">
+ <Users className="h-6 w-6" />
+ <h1 className="text-3xl font-bold">벤더 진척도 현황</h1>
+ </div>
+ <Button
+ onClick={loadVendorProgress}
+ disabled={loading}
+ className="flex items-center gap-2"
+ >
+ {loading ? <Loader className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
+ 새로고침
+ </Button>
</div>
- {/* Input Parameters */}
+ {/* Vendor Progress List */}
<Card>
<CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 테스트 파라미터
- </CardTitle>
- <CardDescription>
- 아래 값들을 수정하여 다양한 시나리오를 테스트할 수 있습니다.
- </CardDescription>
+ <CardTitle>벤더별 작업 진척도</CardTitle>
</CardHeader>
<CardContent>
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
- <div className="space-y-2">
- <Label htmlFor="contractItemId">Contract Item ID</Label>
- <Input
- id="contractItemId"
- value={contractItemId}
- onChange={(e) => setContractItemId(e.target.value)}
- placeholder="123"
- />
+ {loading ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader className="h-6 w-6 animate-spin mr-2" />
+ <span>벤더 진척도를 불러오는 중...</span>
</div>
- <div className="space-y-2">
- <Label htmlFor="formCode">Form Code</Label>
- <Input
- id="formCode"
- value={formCode}
- onChange={(e) => setFormCode(e.target.value)}
- placeholder="SPR_LST"
- />
+ ) : vendorProgress.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 벤더 데이터가 없습니다.
</div>
+ ) : (
<div className="space-y-2">
- <Label htmlFor="projectId">Project ID</Label>
- <Input
- id="projectId"
- value={projectId}
- onChange={(e) => setProjectId(e.target.value)}
- placeholder="1"
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="vendorId">Vendor ID</Label>
- <Input
- id="vendorId"
- value={vendorId}
- onChange={(e) => setVendorId(e.target.value)}
- placeholder="1"
- />
+ {/* Header */}
+ <div className="grid grid-cols-6 gap-4 p-3 bg-muted rounded-lg font-semibold text-sm">
+ <div>벤더명</div>
+ <div className="text-center">폼 개수</div>
+ <div className="text-center">태그 개수</div>
+ <div className="text-center">전체 필드</div>
+ <div className="text-center">입력 필드</div>
+ <div className="text-center">완성도</div>
+ </div>
+
+ {/* Vendor Rows */}
+ <ScrollArea className="h-96">
+ <div className="space-y-1">
+ {vendorProgress.map((vendor) => (
+ <div key={vendor.vendorId} className="grid grid-cols-6 gap-4 p-3 border rounded-lg hover:bg-muted/50">
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-center">{vendor.totalForms}</div>
+ <div className="text-center">{vendor.tagCount}</div>
+ <div className="text-center">{vendor.totalRequiredFields}</div>
+ <div className="text-center">{vendor.totalFilledFields}</div>
+ <div className="text-center">
+ <Badge
+ variant={
+ vendor.completionPercentage >= 80 ? "default" :
+ vendor.completionPercentage >= 50 ? "secondary" :
+ "destructive"
+ }
+ >
+ {vendor.completionPercentage}%
+ </Badge>
+ </div>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
</div>
- </div>
+ )}
</CardContent>
</Card>
-
- {/* Test Buttons */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <BarChart className="h-5 w-5" />
- 테스트 액션들
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- <Button
- onClick={() => handleTest('vendor-form', () =>
- calculateVendorFormCompletion(Number(contractItemId), formCode)
- )}
- disabled={loading !== null}
- className="h-auto p-4 flex flex-col items-start space-y-2"
- >
- {loading === 'vendor-form' && <Loader className="h-4 w-4 animate-spin" />}
- <div className="font-semibold">벤더 폼 완성도</div>
- <div className="text-sm opacity-80">특정 벤더의 특정 폼 완성도</div>
- </Button>
-
- <Button
- onClick={() => handleTest('project-summary', () =>
- getProjectVendorCompletionSummary(Number(projectId), formCode)
- )}
- disabled={loading !== null}
- variant="outline"
- className="h-auto p-4 flex flex-col items-start space-y-2"
- >
- {loading === 'project-summary' && <Loader className="h-4 w-4 animate-spin" />}
- <div className="font-semibold">프로젝트 요약</div>
- <div className="text-sm opacity-80">프로젝트의 모든 벤더 완성도</div>
- </Button>
-
- <Button
- onClick={() => handleTest('vendor-contract', () =>
- calculateVendorContractCompletion(Number(vendorId), Number(contractItemId))
- )}
- disabled={loading !== null}
- variant="outline"
- className="h-auto p-4 flex flex-col items-start space-y-2"
- >
- {loading === 'vendor-contract' && <Loader className="h-4 w-4 animate-spin" />}
- <div className="font-semibold">벤더 계약 완성도</div>
- <div className="text-sm opacity-80">특정 벤더의 특정 계약 완성도</div>
- </Button>
-
- <Button
- onClick={() => handleTest('vendor-all-contracts', () =>
- getVendorAllContractsCompletionSummary(Number(vendorId))
- )}
- disabled={loading !== null}
- variant="secondary"
- className="h-auto p-4 flex flex-col items-start space-y-2"
- >
- {loading === 'vendor-all-contracts' && <Loader className="h-4 w-4 animate-spin" />}
- <div className="font-semibold">벤더 전체 계약</div>
- <div className="text-sm opacity-80">벤더의 모든 계약 완성도</div>
- </Button>
-
- <Button
- onClick={() => handleTest('all-vendors', () =>
- getAllVendorsContractsCompletionSummary()
- )}
- disabled={loading !== null}
- variant="secondary"
- className="h-auto p-4 flex flex-col items-start space-y-2"
- >
- {loading === 'all-vendors' && <Loader className="h-4 w-4 animate-spin" />}
- <div className="font-semibold">전체 벤더 요약</div>
- <div className="text-sm opacity-80">모든 벤더의 계약 완성도</div>
- </Button>
-
- <Button
- onClick={() => handleTest('all-projects', () =>
- getAllProjectsVendorCompletionSummary()
- )}
- disabled={loading !== null}
- variant="secondary"
- className="h-auto p-4 flex flex-col items-start space-y-2"
- >
- {loading === 'all-projects' && <Loader className="h-4 w-4 animate-spin" />}
- <div className="font-semibold">전체 프로젝트 요약</div>
- <div className="text-sm opacity-80">모든 프로젝트의 벤더 완성도</div>
- </Button>
- </div>
- </CardContent>
- </Card>
-
- <Separator />
-
- {/* Results */}
- {results && (
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <TrendingUp className="h-5 w-5" />
- 테스트 결과: {results.type}
- </CardTitle>
- </CardHeader>
- <CardContent>
- {!results.data ? (
- <div className="text-center py-8 text-muted-foreground">
- 데이터가 없습니다. 파라미터를 확인해주세요.
- </div>
- ) : results.type === 'vendor-form' ? (
- renderVendorFormStats(results.data as VendorFormCompletionStats)
- ) : results.type === 'project-summary' ? (
- renderProjectSummary(results.data as ProjectVendorCompletionSummary)
- ) : results.type === 'vendor-all-contracts' ? (
- renderVendorAllContracts(results.data as VendorAllContractsCompletionSummary)
- ) : (
- <div className="space-y-4">
- <div className="bg-muted p-4 rounded-lg">
- <pre className="text-sm overflow-auto max-h-96">
- {JSON.stringify(results.data, null, 2)}
- </pre>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
- )}
</div>
);
-}
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx
index b675aed1..eb5e62d0 100644
--- a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx
@@ -61,7 +61,7 @@ export default async function SettingsLayout({
{/* 4) 입찰 정보가 있으면 번호 + 제목 + "상세 정보" 표기 */}
<h2 className="text-2xl font-bold tracking-tight">
{bidding
- ? `${bidding.biddingNumber ?? ""} - ${bidding.title}`
+ ? `입찰 No. ${bidding.biddingNumber ?? ""} - ${bidding.title}`
: "Loading Bidding..."}
</h2>
</div>
diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx
index e2c22b22..64d6d740 100644
--- a/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx
+++ b/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx
@@ -1,7 +1,8 @@
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getBiddingDetailData } from '@/lib/bidding/detail/service'
-import { BiddingDetailContent } from '@/lib/bidding/detail/table/bidding-detail-content'
+import { getBiddingCompanies } from '@/lib/bidding/pre-quote/service'
+import { BiddingPreQuoteContent } from '@/lib/bidding/pre-quote/table/bidding-pre-quote-content'
// 메타데이터 생성
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
@@ -38,13 +39,17 @@ export default async function Page({ params }: PageProps) {
notFound()
}
+ // 사전견적용 입찰 업체들 조회
+ const biddingCompaniesResult = await getBiddingCompanies(parsedId)
+ const biddingCompanies = biddingCompaniesResult.success ? biddingCompaniesResult.data : []
+
return (
<Suspense fallback={<div className="p-8">로딩 중...</div>}>
- <BiddingDetailContent
+ <BiddingPreQuoteContent
bidding={detailData.bidding}
quotationDetails={detailData.quotationDetails}
quotationVendors={detailData.quotationVendors}
- biddingCompanies={detailData.biddingCompanies}
+ biddingCompanies={biddingCompanies}
prItems={detailData.prItems}
/>
</Suspense>
diff --git a/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx
index 1b058801..999bfe8b 100644
--- a/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/rfq-last/[id]/layout.tsx
@@ -4,9 +4,12 @@ import { Separator } from "@/components/ui/separator"
import { SidebarNav } from "@/components/layout/sidebar-nav"
import { formatDate } from "@/lib/utils"
import { Button } from "@/components/ui/button"
-import { ArrowLeft } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { ArrowLeft, Clock, AlertTriangle, CheckCircle, XCircle, AlertCircle } from "lucide-react"
import { RfqsLastView } from "@/db/schema"
import { findRfqLastById } from "@/lib/rfq-last/service"
+import { differenceInDays } from "date-fns"
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"
export const metadata: Metadata = {
title: "견적 목록 상세",
@@ -23,30 +26,92 @@ export default async function RfqLayout({
// 1) URL 파라미터에서 id 추출, Number로 변환
const resolvedParams = await params
const lng = resolvedParams.lng
- const id = resolvedParams.id
+ const rfqId = parseInt(resolvedParams.id, 10);
+
+ if (!rfqId || isNaN(rfqId) || rfqId <= 0) {
+ return (
+ <div className="p-4">
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertTitle>오류</AlertTitle>
+ <AlertDescription>
+ 유효하지 않은 RFQ입니다.
+ </AlertDescription>
+ </Alert>
+ </div>
+ );
+ }
+
- const idAsNumber = Number(id)
// 2) DB에서 해당 협력업체 정보 조회
- const rfq: RfqsLastView | null = await findRfqLastById(idAsNumber)
+ const rfq: RfqsLastView | null = await findRfqLastById(rfqId)
// 3) 사이드바 메뉴
const sidebarNavItems = [
{
title: "견적 문서관리",
- href: `/${lng}/evcp/rfq-last/${id}`,
+ href: `/${lng}/evcp/rfq-last/${rfqId}`,
},
{
title: "RFQ 발송",
- href: `/${lng}/evcp/rfq-last/${id}/vendor`,
+ href: `/${lng}/evcp/rfq-last/${rfqId}/vendor`,
},
]
+ // Due Date 상태 계산 함수
+ const getDueDateStatus = (dueDate: Date | string | null) => {
+ if (!dueDate) return null;
+
+ const now = new Date();
+ const due = new Date(dueDate);
+ const daysLeft = differenceInDays(due, now);
+
+ if (daysLeft < 0) {
+ return {
+ icon: <XCircle className="h-4 w-4" />,
+ text: `${Math.abs(daysLeft)}일 지남`,
+ className: "text-red-600",
+ bgClassName: "bg-red-50"
+ };
+ } else if (daysLeft === 0) {
+ return {
+ icon: <AlertTriangle className="h-4 w-4" />,
+ text: "오늘 마감",
+ className: "text-orange-600",
+ bgClassName: "bg-orange-50"
+ };
+ } else if (daysLeft <= 3) {
+ return {
+ icon: <AlertCircle className="h-4 w-4" />,
+ text: `${daysLeft}일 남음`,
+ className: "text-amber-600",
+ bgClassName: "bg-amber-50"
+ };
+ } else if (daysLeft <= 7) {
+ return {
+ icon: <Clock className="h-4 w-4" />,
+ text: `${daysLeft}일 남음`,
+ className: "text-blue-600",
+ bgClassName: "bg-blue-50"
+ };
+ } else {
+ return {
+ icon: <CheckCircle className="h-4 w-4" />,
+ text: `${daysLeft}일 남음`,
+ className: "text-green-600",
+ bgClassName: "bg-green-50"
+ };
+ }
+ };
+
+ const dueDateStatus = rfq?.dueDate ? getDueDateStatus(rfq.dueDate) : null;
+
return (
<>
<div className="container py-6">
<section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
<div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="flex items-center justify-end mb-4">
+ <div className="flex items-center justify-end mb-4">
<Link href={`/${lng}/evcp/rfq-last`} passHref>
<Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
<ArrowLeft className="mr-1 h-4 w-4" />
@@ -55,25 +120,38 @@ export default async function RfqLayout({
</Link>
</div>
<div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ {/* 제목 로직 수정: rfqTitle 있으면 사용, 없으면 rfqCode만 표시 */}
<h2 className="text-2xl font-bold tracking-tight">
{rfq
- ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}`
+ ? rfq.rfqTitle
+ ? `견적 상세 관리 ${rfq.rfqCode ?? ""} | ${rfq.rfqTitle}`
+ : `견적 상세 관리 ${rfq.rfqCode ?? ""}`
: "Loading RFQ..."}
</h2>
-
- <p className="text-muted-foreground">
- RFQ 관리하는 화면입니다.
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
+
+ {/* <p className="text-muted-foreground">
+ RFQ 관리하는 화면입니다.
+ </p> */}
+
+ {/* Due Date 표시 개선 */}
+ {rfq?.dueDate && dueDateStatus && (
+ <div className="flex items-center gap-3 pt-2">
+ <span className="text-sm font-medium text-muted-foreground">Due Date:</span>
+ <strong className="text-sm">{formatDate(rfq.dueDate, "KR")}</strong>
+ <div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${dueDateStatus.bgClassName} ${dueDateStatus.className}`}>
+ {dueDateStatus.icon}
+ <span className="text-xs font-medium">{dueDateStatus.text}</span>
+ </div>
+ </div>
+ )}
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
+ <aside className="lg:w-64 flex-shrink-0">
+ <SidebarNav items={sidebarNavItems} />
</aside>
<div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
+ </div>
</div>
</section>
</div>
diff --git a/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx b/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx
index 6819e122..1ccb7559 100644
--- a/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/rfq-last/[id]/page.tsx
@@ -3,6 +3,9 @@ import { type SearchParams } from "@/types/table"
import { getValidFilters } from "@/lib/data-table"
import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations"
import { getRfqLastAttachments } from "@/lib/rfq-last/service"
+import { RfqAttachmentsTable } from "@/lib/rfq-last/attachment/rfq-attachments-table"
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"
+import { AlertCircle } from "lucide-react"
interface IndexPageProps {
// Next.js 13 App Router에서 기본으로 주어지는 객체들
@@ -16,21 +19,61 @@ interface IndexPageProps {
export default async function RfqPage(props: IndexPageProps) {
const resolvedParams = await props.params
const lng = resolvedParams.lng
- const id = resolvedParams.id
+ const rfqId = parseInt(resolvedParams.id, 10);
- const idAsNumber = Number(id)
+ if (!rfqId || isNaN(rfqId) || rfqId <= 0) {
+ return (
+ <div className="p-4">
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertTitle>오류</AlertTitle>
+ <AlertDescription>
+ 유효하지 않은 RFQ입니다.
+ </AlertDescription>
+ </Alert>
+ </div>
+ );
+ }
// 2) SearchParams 파싱 (Zod)
// - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqAttachmentsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
+ const searchParams = await props.searchParams;
+ const activeTab = searchParams.tab || '설계';
+
+ // 활성 탭에 따라 다른 파라미터 파싱
+ const designSearch = activeTab === '설계'
+ ? searchParamsRfqAttachmentsCache.parse({
+ ...searchParams,
+ // design_ prefix가 붙은 파라미터들 추출
+ page: searchParams.design_page,
+ perPage: searchParams.design_perPage,
+ sort: searchParams.design_sort,
+ filters: searchParams.design_filters,
+ })
+ : { page: 1, perPage: 10, sort: [], filters: [] };
+
+ const purchaseSearch = activeTab === '구매'
+ ? searchParamsRfqAttachmentsCache.parse({
+ ...searchParams,
+ // purchase_ prefix가 붙은 파라미터들 추출
+ page: searchParams.purchase_page,
+ perPage: searchParams.purchase_perPage,
+ sort: searchParams.purchase_sort,
+ filters: searchParams.purchase_filters,
+ })
+ : { page: 1, perPage: 10, sort: [], filters: [] };
+
+ // 활성 탭의 데이터만 실제로 가져오기
+ const [designData, purchaseData] = await Promise.all([
+ activeTab === '설계'
+ ? getRfqLastAttachments({ ...designSearch }, rfqId, "설계")
+ : { data: [], pageCount: 0 },
+ activeTab === '구매'
+ ? getRfqLastAttachments({ ...purchaseSearch }, rfqId, "구매")
+ : { data: [], pageCount: 0 }
+ ]);
- const promises = getRfqLastAttachments({
- ...search,
- filters: validFilters,
- }, idAsNumber)
// 4) 렌더링
return (
@@ -45,7 +88,11 @@ export default async function RfqPage(props: IndexPageProps) {
</div>
<Separator />
<div>
- {/* <RfqAttachmentsTable promises={promises} rfqId={idAsNumber} /> */}
+ <RfqAttachmentsTable
+ rfqId={rfqId}
+ initialDesignData={designData}
+ initialPurchaseData={purchaseData}
+ />
</div>
</div>
)
diff --git a/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx b/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx
new file mode 100644
index 00000000..6364f7f8
--- /dev/null
+++ b/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx
@@ -0,0 +1,97 @@
+import { PartnersBiddingPreQuote } from '@/lib/bidding/vendor/partners-bidding-pre-quote'
+import { Suspense } from 'react'
+import { Skeleton } from '@/components/ui/skeleton'
+
+import { getServerSession } from 'next-auth'
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+interface PartnersPreQuotePageProps {
+ params: Promise<{
+ id: string
+ }>
+}
+
+export default async function PartnersPreQuotePage(props: PartnersPreQuotePageProps) {
+ const resolvedParams = await props.params
+ const biddingId = parseInt(resolvedParams.id)
+
+ if (isNaN(biddingId)) {
+ return (
+ <div className="container mx-auto py-6">
+ <div className="text-center">
+ <h1 className="text-2xl font-bold text-destructive">유효하지 않은 입찰 ID입니다.</h1>
+ </div>
+ </div>
+ )
+ }
+
+ // 세션에서 companyId 가져오기
+ const session = await getServerSession(authOptions)
+ const companyId = session?.user?.companyId
+
+ if (!companyId) {
+ return (
+ <div className="container mx-auto py-6">
+ <div className="text-center">
+ <h1 className="text-2xl font-bold text-destructive">회사 정보가 없습니다. 다시 로그인 해주세요.</h1>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="container mx-auto py-6">
+ <Suspense fallback={<PreQuoteSkeleton />}>
+ <PartnersBiddingPreQuote
+ biddingId={biddingId}
+ companyId={companyId}
+ />
+ </Suspense>
+ </div>
+ )
+}
+
+function PreQuoteSkeleton() {
+ return (
+ <div className="space-y-6">
+ {/* 헤더 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <div className="space-y-2">
+ <Skeleton className="h-8 w-64" />
+ <Skeleton className="h-4 w-48" />
+ </div>
+ </div>
+
+ {/* 입찰 공고 스켈레톤 */}
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-32" />
+ <div className="space-y-2">
+ {Array.from({ length: 6 }).map((_, i) => (
+ <Skeleton key={i} className="h-6 w-full" />
+ ))}
+ </div>
+ </div>
+
+ {/* 현재 설정된 조건 스켈레톤 */}
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-32" />
+ <div className="grid grid-cols-2 gap-4">
+ {Array.from({ length: 8 }).map((_, i) => (
+ <Skeleton key={i} className="h-16 w-full" />
+ ))}
+ </div>
+ </div>
+
+ {/* 사전견적 폼 스켈레톤 */}
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-32" />
+ <div className="space-y-4">
+ {Array.from({ length: 10 }).map((_, i) => (
+ <Skeleton key={i} className="h-10 w-full" />
+ ))}
+ <Skeleton className="h-12 w-32" />
+ </div>
+ </div>
+ </div>
+ )
+}